npx create-react-router@latest file-manager
跑完打開資料夾,大致上會有:
app/
root.tsx
routes.ts
routes/
home.tsx
react-router.config.ts
vite.config.ts
有進入點、有路由設定、有型別產生、有建置設定——至少不是一片空白。
然後開始放第一個元件。
FileList.tsx 要放哪?它不是路由,不屬於 routes/。放 app/ 底下跟 root.tsx 並排?那 hook 呢?API 呼叫呢?共用的 Button 呢?
框架告訴我路由住在哪裡,但沒告訴我其他東西住在哪裡。
骨架有了,骨架之外全是空白。React又開始給我這個該死的自由了,海的另一邊是自由嗎?
我原本以為 ng g co file-row 的價值是省下打字。
錯了。它真正做的是替我做完一連串決定:
.ts、.html、.scss、.spec.ts 四個檔FileRowComponent,selector 加 app- 前綴以前沒有為這些事情煩惱過一秒鐘,因為 CLI 早就決定好了。真懷念飯來張口當伸手牌的日子。
而且全世界的 Angular 專案長得都差不多——換一家公司、接手一個陌生專案,打開 src/app 就知道東西在哪。這不是因為 Angular 工程師有默契,是因為大家用同一份被提供的建議。
React Router 也有建議,但範圍很窄。它規定了三件事(當然我最近也有看過不同的設計,下面講廣義的):
routes/,並在 routes.ts 登記react-router.config.ts
這三件事之外,一律可以自由發揮。但說實在Angular有時也可以發揮啦啦,但會覺得為啥這麼亂搞而已。
所以「沒有 CLI」不是少了一個產生器,是少了一份完整的建議。剩下的八成,得由你自己提供。
面對那八成的空白,我做了最自然的事:複製貼上我熟悉的東西。
app/
core/
services/
interceptors/
guards/
shared/
components/
pipes/
utils/
features/
files/
settings/
routes/
core 放只能有一份的東西、shared 放到處都在用的東西、features 放各個功能——這套結構大致就是這樣。
當下的感覺是:終於有點熟悉的味道了。
先講結論:不是這套結構不好,是它在 React 裡沒有東西可以依附。
core/ 失去了判準core 在 Angular 存在的理由很明確:那些只能被根模組注入一次的東西。HttpInterceptor、providedIn: 'root' 的 service、route guard——它們有一個共同的身分,由 NgModule 和 injector 定義。
React 沒有 module,沒有 injector。一個單例就是 export const apiClient = ...(Day 2 講過),任何檔案都能放,也沒有誰規定它非得在某一層註冊。
於是 core/ 沒有了判準。它從「單例的家」變成「我不知道該放哪的東西的家」,裡面躺著彼此無關的檔案。
shared/ 變成垃圾場這件事在 Angular 也會發生,但那邊至少有 SharedModule 逼你顯式列出 exports——每加一個東西,得在 module 裡寫一行,那一行就是一次小小的自我審查。
React 沒有這道關卡。
features/ 跟 routes/ 打架這是最煩的一個。框架規定路由住 routes/,於是同一個功能被拆成兩半:
app/routes/files.tsx ← 路由與 loader 在這
app/features/files/ ← 元件與邏輯在這
改一個頁面,兩個資料夾來回跳。而且 routes/files.tsx 裡常常只剩三行——export 一個 loader、export 一個元件、從 features 裡 import 進來。
盯著這個結構看了一陣子,我才想通:
Angular 的資料夾結構,是 module 階層的投影。
core、shared、features 之所以是那三個,因為它們對應 CoreModule、SharedModule、FeatureModule——資料夾只是把那個階層畫在檔案系統上而已。真正立規矩的是 module,資料夾只是它的影子。
我把影子搬過來了,但投下影子的那個東西不存在。
我全部打掉重排,換成一條原則:就近放置(colocation)。
東西放在用到它的地方旁邊,而不是按「型別」分類。
app/
routes/
files.tsx ← 路由與 loader
files.$id.tsx
features/
files/
FileList.tsx
FileRow.tsx
useFiles.ts ← 這個功能的 hook
api.ts ← 這個功能的 API 呼叫
types.ts
settings/
...
components/ ← 真的到處都在用的才進來
Button.tsx
Modal.tsx
lib/
apiClient.ts
format.ts
差別在哪?舊的結構問「這是什麼型別類型的檔案」,新的結構問「這是誰的東西」。
useFiles.ts 是一個 hook,但它只服務檔案功能,所以它住在 features/files/ 而不是某個統一的 hooks/ 資料夾。改檔案功能的時候,相關的東西全在同一個資料夾裡,不用在四個地方跳。
routes/files.tsx 依然只有幾行,但心態變了——我不再覺得它是被切走的一半,而是功能對外的接口:路由在這裡宣告我需要什麼資料、要渲染誰,實作在 features 裡。
至於什麼時候該把東西往上搬,只有一條規則:
等到第三個地方用到它,再往上搬。 在那之前,留在原地。
兩個地方用,複製一份沒關係。過早抽出來的共用元件,通常會在第三個需求進來時被改成一個滿是 if 的四不像。
Standalone components 普及之後,NgModule 逐漸淡出。而 NgModule 一走,core/shared/features 那套結構的必要性也跟著下降——它本來就是 module 階層的投影,投影源消失了,影子自然要重畫。
現在的 Angular 專案也愈來愈多採用按功能就近放置。這件事上,兩邊其實在靠攏。
只是大多專案因為公司常有固定規範,所以即使換了版本但內裡其實不換的,因為停留在舊時代的人不好維護。
我搬過來的那套結構,某種意義上不只是「Angular 的結構」,而是「我熟悉的那個版本的 Angular 的結構」。
沒有 CLI 幫你決定,就得自己決定——而且要寫下來,不然第三個人進專案時會出現第三種寫法。
但記得把討論後的放進 README 裡面:
檔名。 FileRow.tsx 還是 file-row.tsx?Angular CLI 只給一個答案,React 社群兩派都很多。我們選了元件用 PascalCase、其他用 camelCase,理由只是「檔名跟它 export 的東西一致」。
barrel file。 每個資料夾要不要放 index.ts 當統一出口?看起來乾淨,但會拖累 tree-shaking、也容易製造循環引用。也是維持著兩派論點。
測試檔位置。 放元件旁邊(FileRow.test.tsx)還是集中到 __tests__/?既然走就近放置,測試當然也放旁邊。
Lint 設定。 ng lint 沒有了,ESLint 加 Prettier 要自己組。eslint-plugin-react-hooks 一定要裝——Day 4 那個依賴陣列的坑就靠它擋一半。
這些決定沒有標準答案,重點不在選哪個,在選了就統一。
Angular 把這份統一內建在 CLI 裡,所以你不用開會討論。React 要你自己開那個會,目前待的專案就常討論結構問題,後來才發現之前被 Angular 保護太好。想一想我剛進 React 專案卻想套用 Angular 的分類方式,沒人說不行,但或許會有更適合的方式,畢竟方式是人討論出來的。
回頭看這七天不見的東西:DI、模板語法、生命週期、變更偵測、雙向綁定、專案結構。
寫的時候我以為是六個獨立的主題。排在一起才發現,它們是同一件事的六個面貌:
Angular 把大量決定內建成框架的一部分——誰負責建立實例、什麼時候重新渲染、資料能往哪流、檔案該放哪。你不需要有意見,因為框架已經有了。
React 幾乎什麼都不決定。它給你一個渲染函式,跟一句「其他的你自己看著辦」。
這週我一直在做同一件事:找對應寫法。ngOnInit 對應什麼、@if 對應什麼、providedIn: 'root' 對應什麼。而答案一次比一次更接近同一句——
沒有對應寫法。那是一個決定,現在輪到你來做。
大致接受了「東西真的不見了」這件事。但本次提到的專案結構啊,說到底每家公司做法不同,打一架吧,打完厲害的有說服力的就會被留下來。
但接受不等於服氣。下週要進入抗拒期:我還不打算放棄 Angular 的做法。 我要把 RxJS 那一整套思維原封不動搬過去——switchMap、forkJoin、debounceTime,全部自己手刻。
然後看它撞牆。
明天先從最根本的開始:Observable 跟 Promise,到底差在哪。